iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0

昨天把 MCP 的兩種傳輸方式拆開來看。

stdio 適合跟著本機應用一起跑,Streamable HTTP 則適合獨立部署成服務。

今天先不再拆協定了。

前九天學過的東西全部拿回來,做出第一個真的能用的 MCP Server:devbench

它是一個本機開發者工作台,提供:

Tools
├── read_file
├── list_files
├── run_tests
└── git_log

Resources
├── devbench://project/summary
└── devbench://file/{path}

Prompt
└── code_review

最後再把它掛進 Claude Code,真的用起來。

Day 10 程式實測結果

一、先處理一個版本差異

這次實作用的是 MCP Python SDK 2.x。

如果照一些舊教學寫:

from mcp.server.fastmcp import FastMCP

會直接遇到 ModuleNotFoundError

現在這份實作使用:

from mcp.server.mcpserver import MCPServer

另外 SDK 裡 Python 物件的欄位名稱也改成 snake_case。

例如:

protocolVersion → protocol_version
inputSchema     → input_schema
isError         → is_error

但這只是 Python API 的命名方式改了。

MCP 在線上傳送的 JSON 仍然維持協定定義的 camelCase。

這個差異如果不知道,很容易出現一種情況:

JSON 看起來完全正常,但 Python 一直噴 AttributeError

二、先做四個 Tools

devbench 第一版先提供四個工具:

Tool 用途
read_file 讀取專案檔案
list_files 搜尋專案內的檔案
run_tests 執行 pytest
git_log 查看 Git commit

例如 read_file

@mcp.tool(
    annotations=ToolAnnotations(
        read_only_hint=True,
        destructive_hint=False,
        idempotent_hint=True,
    )
)
def read_file(
    path: Annotated[
        str,
        Field(description="相對於專案根目錄的檔案路徑")
    ],
    max_lines: Annotated[
        int,
        Field(default=200, ge=1, le=5000)
    ] = 200,
) -> str:
    """讀取專案內的一個檔案並回傳內容。"""

    target = safe_path(path)

    if not target.is_file():
        return f"找不到檔案:{path}"

    lines = target.read_text(
        encoding="utf-8",
        errors="replace",
    ).splitlines()

    return "\n".join(lines[:max_lines])

這裡其實把前面幾天的東西全部串起來了。

AnnotatedField 會被 SDK 轉成 Tool schema,模型因此知道參數名稱、型別和用途。

ToolAnnotations 則是在描述這個 Tool 的行為特性:

read_only_hint
destructive_hint
idempotent_hint

例如 read_file 是唯讀、不具破壞性,而且重複執行通常會得到相同效果。

這些是提供給 Host 的提示。

Host 可以依照這些資訊調整 UI、權限流程或確認機制,但它本身不是安全保證。

三、Tool 能讀檔之後,第一件事不是功能,是邊界

只要 Tool 接受檔案路徑,就會立刻遇到一個問題:

../../../etc/passwd

如果直接:

PROJECT_ROOT / path

模型或使用者就有機會一路往專案目錄外面走。

所以先做一個 safe_path()

def safe_path(rel: str) -> Path:
    if rel.startswith("/") or "\x00" in rel:
        raise ValueError("只接受相對路徑")

    target = (PROJECT_ROOT / rel).resolve()

    if not target.is_relative_to(PROJECT_ROOT):
        raise ValueError("路徑逃出專案根目錄")

    return target

這裡做了三件事:

1. 不接受絕對路徑
2. 不接受 null byte
3. resolve 後再次確認還在專案根目錄內

第三個最重要。

因為只檢查:

..

其實不夠。

假設專案內有一個 symbolic link 指向外部目錄,路徑字串本身可能完全沒有 ..,最後還是能讀到專案外的檔案。

所以要先:

.resolve()

把真正位置展開,再檢查它是不是還位於 PROJECT_ROOT 裡。

這也是 Tool 開始具備實際能力後,第一個真正需要自己負責的安全邊界。

四、錯誤也要分種類

接著故意攻擊一次:

await session.call_tool(
    "read_file",
    {"path": "../../../etc/passwd"},
)

實測結果:

isError = True
回傳內容 → Error executing tool read_file

路徑穿越成功被擋下來,而且 Server 沒有把完整例外內容直接送給 Client。

這個行為滿重要。

因為例外訊息有時候會包含:

絕對路徑
使用者名稱
內部目錄結構
套件資訊

不一定適合直接暴露給模型。

所以我在這裡把錯誤分成兩種。

像:

找不到檔案

是正常操作可能遇到的情況,直接 return

return f"找不到檔案:{path}"

模型拿到訊息後,可以自己修正路徑再試一次。

但像:

路徑穿越

代表輸入已經越過安全邊界,就直接:

raise ValueError(...)

讓 SDK 當成 Tool error 處理。

簡單來說:

預期中的失敗 → 回傳給模型處理
違反安全邊界 → 丟例外

五、Resources 和 Prompt 也一起放進來

Day 7 講過,MCP Server 不只有 Tools。

所以這次也一起放兩個 Resources。

第一個提供專案摘要:

@mcp.resource(
    "devbench://project/summary",
    name="專案摘要",
    mime_type="text/plain",
)
def project_summary() -> str:
    ...

Client 可以讀:

devbench://project/summary

取得專案的 Python 檔案數量、程式碼行數和目錄概況。

第二個則是 Resource Template:

@mcp.resource(
    "devbench://file/{path}",
    name="專案檔案",
    mime_type="text/plain",
)
def file_resource(path: str) -> str:
    return safe_path(path).read_text(
        encoding="utf-8",
        errors="replace",
    )

所以同一個 Server 裡,現在同時有:

模型主動決定 → Tools

Host 主動載入 → Resources

使用者主動選擇 → Prompts

Prompt 則做成一個固定的 Code Review 流程:

@mcp.prompt(
    name="code_review",
    title="程式碼審查",
)
def code_review(
    path: str,
    focus: Literal["安全性", "效能", "可讀性", "全部"] = "全部",
) -> str:
    return (
        f"請審查 `{path}`,重點放在「{focus}」。\n"
        "請檢查例外處理、外部輸入、效能與可讀性。"
    )

這樣 Client 不需要每次重新拼一整段 Code Review Prompt。

六、真的跑一輪 MCP

Server 寫好之後,我沒有直接假設它能動。

另外寫一個 Client,真的照 MCP 流程跑一次:

initialize

↓
tools/list
↓
tools/call
↓
resources/list
↓
resources/read
↓
prompts/list
↓
prompts/get

Client 連上之後可以看到:

協定版本      2025-11-25
Server        devbench v0.1.0
宣告的能力    tools=True resources=True prompts=True

接著也能讀到 Tool 的 schema 和 annotations:

read_file
參數 ['path', 'max_lines']
必填 ['path']

唯讀=True
破壞性=False
冪等=True

這裡其實就是前幾天內容的總驗收。

Day 7 的三大原語、Day 8 的 JSON-RPC、Day 9 的 stdio,現在全部串在同一個程式裡。

七、最後掛進 Claude Code

因為 devbench 需要讀本機專案,所以這次選 Day 9 講過的 stdio

在專案根目錄放:

{
  "mcpServers": {
    "devbench": {
      "command": "uv",
      "args": [
        "run",
        "python",
        "days/day10_mcp_server/server.py"
      ]
    }
  }
}

Claude Code 啟動時,就會:

啟動 devbench 行程
        ↓
建立 MCP Client
        ↓
initialize
        ↓
取得 Tools / Resources / Prompts

到這裡,它不再只是我們自己寫的 Demo Client 才能使用。

Claude Code 也能直接看到:

read_file
list_files
run_tests
git_log

以及 code_review Prompt。

前面九天一直在拆 MCP。

今天終於第一次把它裝回一個真的可以使用的東西。

八、這次踩到的兩個坑

第一個是測試。

我沒有 mock MCP Server,而是真的在測試裡啟動一個 stdio Server,再讓官方 Client 連進去。

原本把 Session 做成 pytest async fixture,結果遇到:

Attempted to exit cancel scope in a different task

最後改成:

@asynccontextmanager
async def devbench():
    ...

讓建立與關閉 Client 留在同一個 task 裡,問題就消失了。

第二個是 run_tests

執行 pytest 時我沒有組成一整條 shell command:

subprocess.run(
    ["uv", "run", "pytest", target, "-q"],
    ...
)

而是直接傳 argument list。

這樣不會經過 shell 字串展開,也避開了一類常見的 shell injection 問題。

target 本身還是要做路徑驗證。

不經過 shell,不代表輸入就不用驗證。


Day 10 做完後,手上終於有一個能真正使用的 MCP Server:

devbench
├── Tools
├── Resources
├── Prompt
├── 路徑安全
├── 端到端測試
└── Claude Code

不過現在還有一件事沒有發生。

目前都是「人」在決定要呼叫哪個 Tool。

如果把 devbench 直接交給模型,讓模型自己觀察任務、挑工具、取得結果,再決定下一步呢?

這就開始碰到 Agent 了。

明天進入 Day 11:

從 MCP 到 Agent:模型、工具與 Agent 框架各自負責什麼?


上一篇
Day 9:傳輸協定 stdio vs Streamable HTTP,有狀態與無狀態
系列文
協定、框架、架構:一條龍搞懂 AI Agent 是怎麼被造出來的10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言